セキュアな通信のCloud Run Functionsを作成したいのでDirect VPC Egress(プレビュー)を試してみた #cm_google_cloud_adcal_2024
データ事業本部のsutoです。
この記事はクラスメソッドの Google Cloud アドベントカレンダー2024 の2日目の記事です。
Cloud Run Functionsの外部アクセスを制限し、VPC経由によるプライベート通信経路にしたいという要件はよくあるかと思います。
Google Cloudでこれを実現するための方法がいくつかありますが、今回は__Direct VPC Egress__を利用する方法を検証してみたいと思います。
実装したい構成
今回の検証で実装したいことは以下となります。
- 検証用Cloud Run Functionsを作成し、Google Storage APIのアクセス可能か確認、またインターネットアクセスを確認する処理を記述する
- 作成したCloud Run Functionsが、インターネットアクセスできないように構成する
- 作成したCloud Run Functionsが、VPC経由でGoogle APIにのみアクセスできるように構成する
Direct VPC Egress
これまでの Cloud Run では、VPC 内のリソースやパブリック IP を使用しない Cloud SQL、Memorystore などのリソースに対して接続する場合、サーバーレス VPC アクセスコネクタ のインスタンスを介して VPC に接続する必要がありました。
ただ、Cloud Run で Direct VPC Egress を有効にすると、サーバーレス VPC アクセスコネクタを使用せずに VPC ネットワークにトラフィックを送信できるようになり、機能自体はすでにGAされています。
しかし、GAとなっているのはClou Run ServiceやCloud Run Jobsに対してのみで、本件で検証するCloud Run Functionsにおいては「2024年11月15日時点でプレビュー」の状態ですのでご注意ください。
限定公開のGoogleアクセス
Cloud Run Functions → VPC の通信はDirect VPC Egressで実装しますが、VPC → Google APIの通信は「限定公開のGoogleアクセス」を実装することで実現します。
限定公開のGoogleアクセスの概要は以下の記事が非常にわかりやすいです。
今回は、「VPC Service Controls でサポートされている Google API にだけアクセス可能」というより限定したかたちでやってみようと思うので、Cloud DNSも使って「restricted.googleapis.com」で設定します。
やってみた
検証用のサブネットと関数を作成
まず関数のトラフィック用のサブネット(test-functions-subnet)を作成します。
(VPCのファイアウォール ルールには外部IPの許可はしていないものとなります)
- サブネットでは「限定公開のGoogleアクセス」をオンに設定
次にCloud Run関数を作成します。
- タブ「コンテナ」では関数内で使用する環境変数を追加
- タブ「ネットワーキング」で「アウトバウンドトラフィックを送信する」にチェックし、「VPCに直接トラフィックを送信する」にすることでDirect VPC Egressを設定
- 「すべてのトラフィックをVPCにルーティング」を選択することで、すべてのリクエストはVPCのプライベート経由の通信とする
関数のソースコード
関数のソースコードには、pythonを用いて以下のコードを記述しました。
main.pyを表示するにはここをクリック
from flask import jsonify, Request
import functions_framework
from google.cloud import logging, storage
import os
from retrying import retry
import requests
from logging import DEBUG, INFO, getLogger
logging_client = logging.Client()
logging_client.setup_logging()
logger = getLogger(__name__)
logger.setLevel(DEBUG)
@retry(stop_max_attempt_number=3, wait_exponential_multiplier=1000)
def list_files_from_bucket(bucket_name):
try:
storage_client = storage.Client()
blobs = storage_client.list_blobs(bucket_name)
page_token = None
file_list = []
while True:
blobs = storage_client.list_blobs(bucket_name, page_token=page_token, max_results=10000)
file_list.extend(blob.name for blob in blobs if not blob.name.endswith('/'))
page_token = blobs.next_page_token
logger.debug(f"page_token:{page_token}")
if page_token is None:
break
if not file_list:
logger.warning(f"No files found in bucket {bucket_name}")
raise ValueError(f"No files found in bucket {bucket_name}")
return file_list
except Exception as e:
logger.error(f"Error listing files from bucket {bucket_name}: {e}")
raise
@retry(stop_max_attempt_number=3, wait_exponential_multiplier=1000)
def check_internet(request_url):
try:
# Googleにリクエストを送信して接続を確認
response = requests.get(request_url, timeout=5)
response.raise_for_status() # HTTPエラーを例外として発生させる
# ステータスコードが200の場合、インターネット接続が可能と判断
return jsonify({"status": "success", "message": "Internet connection is available."}), 200
except requests.ConnectionError:
error_message = "No internet connection."
return error_message
except requests.Timeout:
error_message = "Connection timed out."
return error_message
except requests.exceptions.HTTPError as e:
error_message = "HTTP error occurred."
return error_message
except Exception as e:
error_message = "An unexpected error occurred."
return error_message
@functions_framework.http
def test_gcs_get(request: Request):
"""HTTPリクエストを処理して指定バケット配下のファイル名を返す、インターネット接続のレスポンスを返すCloud Function"""
try:
URL= os.getenv("URL")
SRC_BUCKET = os.getenv("SRC_BUCKET")
if not SRC_BUCKET:
raise ValueError("SRC_BUCKET environment variable is not set")
if not URL:
raise ValueError("URL environment variable is not set")
file_names = list_files_from_bucket(SRC_BUCKET)
check_result = check_internet(URL)
return jsonify({"file_names": file_names, "bucket": SRC_BUCKET, "ckeck_connection": check_result}), 200
except ValueError as ve:
logger.exception(f"Value error: {ve}")
return jsonify({"error": str(ve)}), 599
except Exception as e:
logger.exception(f"Unexpected error: {e}")
return jsonify({"error": "Internal Server Error"}), 599
requirements.txtを表示するにはここをクリック
functions-framework==3.*
google-cloud-storage>=1.42.0
google-cloud-logging>=3.0.0
retrying>=1.3.3
追記
Cloud Functionsで作成した既存の関数であっても、Cloud Run画面の方で上記のように編集を行えばDirect VPC Egressの設定が可能です。(Cloud Functionsの画面ではDirect VPC Egressの設定はできません)
限定公開のGoogleアクセスのドメインオプション設定
次にrestricted.googleapis.comを使ったドメイン設定を行います。
今回はルーティングやファイアウォール設定がデフォルトのサブネットで検証していますが、既存のVPCやサブネットを利用する場合は以下を確認してください。
- 199.36.153.4/30 が デフォルトのインターネットゲートウェイへ向いている ことを確認
- VPC ファイアウォールで 199.36.153.4/30 への 443/TCP の 下り (Egress) 通信が拒否されていない ことを確認 (デフォルト状態では許可)
Cloud DNSを開いて「googleapis.com」という限定公開 DNS ゾーンを作成します。
次に作成したゾーンに以下を追加します。
- DNS名: restricted.googleapis.com
- タイプ: A
- IPv4アドレス:199.36.153.4, 199.36.153.5, 199.36.153.6, 199.36.153.7
- DNS名: *.googleapis.com
- タイプ: CNAME
- 正規名: restricted.googleapis.com
動作確認
実際に関数を実行してみたところ、以下のように「外部アクセスはFailed、かつGoogle Storageのバケット一覧は正常に取得」という狙い通りの結果となりました。
参考
サーバーレスVPCアクセスコネクタとDirect VPC Egress比較
実際にサーバーレスVPCアクセスコネクタとDirect VPC Egressどちらを使えばよいかは以下の公式ドキュメントに比較表があるのでご参照ください。
その他の考慮事項
セキュリティの観点については、ネットワーク経路の制御だけでなく、開発者の操作で外部アクセスを許可しないようにするための「組織ポリシー」による制御も考慮する必要があります。
組織ポリシーをどのように設定すればよいかは以下のブログが参考になると思います。
以上
明日12/03は村田一紘さんです。よろしくお願いします!